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,137 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { confirmOccurrenceSchema, recurringRuleFormSchema } from "@/shared/schemas/recurring";
|
||||
import {
|
||||
confirmOccurrence,
|
||||
createRule,
|
||||
deleteRule,
|
||||
getRuleForm,
|
||||
postponeOccurrence,
|
||||
skipOccurrence,
|
||||
updateRule,
|
||||
type RuleFormValues,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
const optionalId = idSchema.optional();
|
||||
const scopeSchema = z.enum(["all", "following"]);
|
||||
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function revalidate() {
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function createRuleAction(input: unknown): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = recurringRuleFormSchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await createRule(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidate();
|
||||
return result.ok ? ok({ id: result.data.id }) : result;
|
||||
}
|
||||
|
||||
export async function updateRuleAction(
|
||||
ruleId: unknown,
|
||||
input: unknown,
|
||||
scope: unknown,
|
||||
fromTransactionId?: unknown,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(ruleId);
|
||||
const s = scopeSchema.safeParse(scope);
|
||||
const from = optionalId.safeParse(fromTransactionId ?? undefined);
|
||||
if (!id.success || !s.success || !from.success) return fail(tr.common.notFound);
|
||||
const parsed = recurringRuleFormSchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await updateRule(
|
||||
ctx.user.id,
|
||||
ctx.household.id,
|
||||
id.data,
|
||||
parsed.data,
|
||||
s.data,
|
||||
from.data,
|
||||
);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteRuleAction(
|
||||
ruleId: unknown,
|
||||
scope: unknown,
|
||||
fromTransactionId?: unknown,
|
||||
): Promise<ActionResult<{ removed: number }>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(ruleId);
|
||||
const s = scopeSchema.safeParse(scope);
|
||||
const from = optionalId.safeParse(fromTransactionId ?? undefined);
|
||||
if (!id.success || !s.success || !from.success) return fail(tr.common.notFound);
|
||||
const result = await deleteRule(ctx.user.id, ctx.household.id, id.data, s.data, from.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getRuleFormAction(ruleId: unknown): Promise<ActionResult<RuleFormValues>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(ruleId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
const values = await getRuleForm(ctx.user.id, ctx.household.id, id.data);
|
||||
return values ? ok(values) : fail(tr.common.notFound);
|
||||
}
|
||||
|
||||
export async function confirmOccurrenceAction(
|
||||
transactionId: unknown,
|
||||
input: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(transactionId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
const parsed = confirmOccurrenceSchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await confirmOccurrence(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function skipOccurrenceAction(transactionId: unknown): Promise<ActionResult> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(transactionId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
const result = await skipOccurrence(ctx.user.id, ctx.household.id, id.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function postponeOccurrenceAction(
|
||||
transactionId: unknown,
|
||||
date: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(transactionId);
|
||||
const d = ymd.safeParse(date);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
if (!d.success) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const result = await postponeOccurrence(ctx.user.id, ctx.household.id, id.data, d.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import {
|
||||
listTransactions,
|
||||
updateTransaction,
|
||||
getTransaction,
|
||||
} from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
confirmOccurrence,
|
||||
createRule,
|
||||
deleteRule,
|
||||
ensureHorizon,
|
||||
getPlannedOverview,
|
||||
postponeOccurrence,
|
||||
skipOccurrence,
|
||||
updateRule,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
const cat = (key: string) =>
|
||||
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||
.lean()
|
||||
.then((c) => String(c!._id));
|
||||
|
||||
async function form(over: Partial<RecurringRuleFormInput> = {}) {
|
||||
return recurringRuleFormSchema.parse({
|
||||
type: "expense",
|
||||
amount: "1.000,00",
|
||||
accountId: await acc("Ziraat Maaş"),
|
||||
categoryId: await cat("faturalar.elektrik"),
|
||||
payee: "Elektrik",
|
||||
startDate: addDays(today, -40),
|
||||
frequency: "monthly",
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
const occurrences = (ruleId: string) =>
|
||||
TransactionModel.find({ recurringRuleId: ruleId }).sort({ occurrenceIndex: 1 }).lean();
|
||||
|
||||
async function balance(accountId: string) {
|
||||
const list = await listAccounts(ayse, cakir);
|
||||
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||
}
|
||||
|
||||
describe("kural ve planlı kayıtlar", () => {
|
||||
let ruleId = "";
|
||||
|
||||
it("kural 12 ay ileriye planlı kayıt üretir; planlı kayıtlar bakiyeyi etkilemez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const before = await balance(ziraat);
|
||||
const r = await createRule(ayse, cakir, await form({ isEstimate: true }));
|
||||
expect(r.ok).toBe(true);
|
||||
ruleId = r.ok ? r.data.id : "";
|
||||
const occ = await occurrences(ruleId);
|
||||
// Başlangıç 40 gün önce: 2 geçmiş + 12 ay ileri (13 ya da 14 kayıt, ay uzunluğuna göre).
|
||||
expect(occ.length).toBeGreaterThanOrEqual(13);
|
||||
expect(occ.every((o) => o.status === "planned" && o.isEstimate)).toBe(true);
|
||||
expect(await balance(ziraat)).toBe(before);
|
||||
expect(await ensureHorizon((await RecurringRuleModel.findById(ruleId))!.householdId)).toBe(0);
|
||||
});
|
||||
|
||||
it("genel bakış: vadesi geçmiş ve yaklaşan; işlem listesi planlıları varsayılan göstermez", async () => {
|
||||
const o = (await getPlannedOverview(ayse, cakir))!;
|
||||
const mine = o.overdue.filter((x) => x.recurringRuleId === ruleId);
|
||||
expect(mine.length).toBeGreaterThanOrEqual(1);
|
||||
expect(o.upcoming.every((x) => x.date >= today)).toBe(true);
|
||||
expect(o.rules.find((x) => x.id === ruleId)).toMatchObject({
|
||||
recurrence: expect.stringContaining("Her ay"),
|
||||
remaining: null,
|
||||
});
|
||||
|
||||
const list = await listTransactions(ayse, cakir, transactionFilterSchema.parse({}));
|
||||
expect(list!.items.some((i) => i.status === "planned")).toBe(false);
|
||||
const planned = await listTransactions(
|
||||
ayse,
|
||||
cakir,
|
||||
transactionFilterSchema.parse({ durum: "planned" }),
|
||||
);
|
||||
expect(planned!.total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("onayla (tutar/tarih düzeltilerek) bakiyeye yansır; atla iptal eder", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const before = await balance(ziraat);
|
||||
const [first, second] = await occurrences(ruleId);
|
||||
const c = await confirmOccurrence(ayse, cakir, String(first!._id), {
|
||||
amount: 1_234_56,
|
||||
date: today,
|
||||
});
|
||||
expect(c.ok).toBe(true);
|
||||
const posted = (await TransactionModel.findById(first!._id).lean())!;
|
||||
expect(posted).toMatchObject({ status: "posted", amountMinor: 1_234_56, isEstimate: false });
|
||||
expect(await balance(ziraat)).toBe(before - 1_234_56);
|
||||
// İkinci kez onaylanamaz.
|
||||
expect(
|
||||
(await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1, date: today })).ok,
|
||||
).toBe(false);
|
||||
|
||||
expect((await skipOccurrence(emre, cakir, String(second!._id))).ok).toBe(true);
|
||||
expect((await TransactionModel.findById(second!._id).lean())!.status).toBe("void");
|
||||
expect(await balance(ziraat)).toBe(before - 1_234_56);
|
||||
});
|
||||
|
||||
it("ertele: tarih ileri alınır, kayıt seriden ayrılır; geçmişe ertelenemez", async () => {
|
||||
const occ = await occurrences(ruleId);
|
||||
const third = occ[2]!;
|
||||
const current = third.date.toISOString();
|
||||
expect((await postponeOccurrence(ayse, cakir, String(third._id), "2000-01-01")).ok).toBe(false);
|
||||
const target = addDays(today, 200);
|
||||
expect((await postponeOccurrence(ayse, cakir, String(third._id), target)).ok).toBe(true);
|
||||
const after = (await TransactionModel.findById(third._id).lean())!;
|
||||
expect(after.detached).toBe(true);
|
||||
expect(after.date.toISOString()).not.toBe(current);
|
||||
});
|
||||
|
||||
it("tümünü güncelle: planlılar yeni tutarla yeniden üretilir; gerçekleşen, atlanan ve ayrılan korunur", async () => {
|
||||
const r = await updateRule(ayse, cakir, ruleId, await form({ amount: "1.500,00" }), "all");
|
||||
expect(r.ok).toBe(true);
|
||||
const occ = await occurrences(ruleId);
|
||||
expect(occ[0]).toMatchObject({ status: "posted", amountMinor: 1_234_56 });
|
||||
expect(occ[1]).toMatchObject({ status: "void" });
|
||||
expect(occ[2]).toMatchObject({ detached: true, amountMinor: 1_000_00 });
|
||||
expect(occ.slice(3).every((o) => o.status === "planned" && o.amountMinor === 1_500_00)).toBe(
|
||||
true,
|
||||
);
|
||||
// Sıra numaraları benzersiz.
|
||||
expect(new Set(occ.map((o) => o.occurrenceIndex)).size).toBe(occ.length);
|
||||
});
|
||||
|
||||
it("bu ve sonrakiler: seri bölünür", async () => {
|
||||
const occ = await occurrences(ruleId);
|
||||
const k = 5;
|
||||
const from = occ.find((o) => o.occurrenceIndex === k)!;
|
||||
const r = await updateRule(
|
||||
ayse,
|
||||
cakir,
|
||||
ruleId,
|
||||
await form({ amount: "2.000,00", startDate: addDays(today, 100) }),
|
||||
"following",
|
||||
String(from._id),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const newId = r.ok ? r.data.id : "";
|
||||
expect(newId).not.toBe(ruleId);
|
||||
expect((await RecurringRuleModel.findById(ruleId).lean())!.count).toBe(k);
|
||||
const old = await occurrences(ruleId);
|
||||
expect(old.every((o) => o.occurrenceIndex! < k)).toBe(true);
|
||||
const fresh = await occurrences(newId);
|
||||
expect(fresh.length).toBeGreaterThan(0);
|
||||
expect(fresh.every((o) => o.amountMinor === 2_000_00)).toBe(true);
|
||||
});
|
||||
|
||||
it("tek kaydı düzenlemek (yalnızca bu) kaydı seriden ayırır", async () => {
|
||||
const r = await createRule(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ startDate: addDays(today, 3), endKind: "count", count: 3 }),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
const occ = await occurrences(id);
|
||||
expect(occ).toHaveLength(3);
|
||||
const d = (await getTransaction(ayse, cakir, String(occ[1]!._id)))!;
|
||||
expect(d.status).toBe("planned");
|
||||
const u = await updateTransaction(ayse, cakir, d.id, {
|
||||
type: "expense",
|
||||
amount: 777_00,
|
||||
date: d.date,
|
||||
accountId: d.account.id,
|
||||
toAccountId: null,
|
||||
categoryId: d.categoryId,
|
||||
splits: [],
|
||||
payee: d.payeeName,
|
||||
description: "",
|
||||
tags: [],
|
||||
attachmentIds: [],
|
||||
});
|
||||
expect(u.ok).toBe(true);
|
||||
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
|
||||
detached: true,
|
||||
status: "planned",
|
||||
amountMinor: 777_00,
|
||||
});
|
||||
// Silme: bu ve sonrakiler (index 1'den) → yalnızca ilk kalır, kural 1 tekrarla biter.
|
||||
expect((await deleteRule(ayse, cakir, id, "following", d.id)).ok).toBe(true);
|
||||
expect(await occurrences(id)).toHaveLength(1);
|
||||
expect((await deleteRule(ayse, cakir, id, "all")).ok).toBe(true);
|
||||
expect(await occurrences(id)).toHaveLength(0);
|
||||
expect((await RecurringRuleModel.findById(id).lean())!.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yetki ve izolasyon", () => {
|
||||
it("izleyici kural oluşturamaz/onaylayamaz; hane dışı göremez", async () => {
|
||||
expect((await createRule(zeynep, cakir, await form())).ok).toBe(false);
|
||||
const o = (await getPlannedOverview(zeynep, cakir))!;
|
||||
const any = o.upcoming[0]!;
|
||||
expect((await confirmOccurrence(zeynep, cakir, any.id, { amount: 1, date: today })).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await getPlannedOverview(fatma, cakir)).toBeNull();
|
||||
expect((await skipOccurrence(fatma, cakir, any.id)).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("gizli hesap kuralı ve kayıtları yalnızca sahibine", async () => {
|
||||
const pluxee = await acc("Ayşe — Pluxee");
|
||||
expect(
|
||||
(
|
||||
await createRule(
|
||||
emre,
|
||||
cakir,
|
||||
await form({ accountId: pluxee, startDate: addDays(today, 1) }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
const r = await createRule(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ accountId: pluxee, startDate: addDays(today, 1), payee: "Gizli kural" }),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
const mine = (await getPlannedOverview(ayse, cakir))!;
|
||||
expect(mine.rules.some((x) => x.id === id)).toBe(true);
|
||||
const theirs = (await getPlannedOverview(emre, cakir))!;
|
||||
expect(theirs.rules.some((x) => x.id === id)).toBe(false);
|
||||
expect(theirs.upcoming.some((x) => x.recurringRuleId === id)).toBe(false);
|
||||
expect((await deleteRule(emre, cakir, id, "all")).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("form doğrulaması: bitiş tarihi başlangıçtan önce olamaz; transferde hedef hesap", () => {
|
||||
expect(
|
||||
recurringRuleFormSchema.safeParse({
|
||||
type: "expense",
|
||||
amount: "10",
|
||||
accountId: "a".repeat(24),
|
||||
categoryId: "b".repeat(24),
|
||||
startDate: "2026-05-01",
|
||||
frequency: "monthly",
|
||||
endKind: "date",
|
||||
endDate: "2026-04-01",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
recurringRuleFormSchema.safeParse({
|
||||
type: "transfer",
|
||||
amount: "10",
|
||||
accountId: "a".repeat(24),
|
||||
startDate: "2026-05-01",
|
||||
frequency: "monthly",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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())! },
|
||||
});
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export async function loadContext(
|
||||
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||
|
||||
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
|
||||
function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||
if (ctx.hiddenAccountIds.length) {
|
||||
f.accountId = { $nin: ctx.hiddenAccountIds };
|
||||
@@ -206,7 +206,7 @@ type Existing = {
|
||||
splits?: { categoryId: Types.ObjectId }[];
|
||||
};
|
||||
|
||||
function buildDoc(
|
||||
export function buildDoc(
|
||||
ctx: Ctx,
|
||||
input: TransactionForm,
|
||||
existing?: Existing,
|
||||
@@ -280,7 +280,7 @@ function buildDoc(
|
||||
}
|
||||
|
||||
/** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */
|
||||
async function touchPayee(
|
||||
export async function touchPayee(
|
||||
householdId: Types.ObjectId,
|
||||
name: string,
|
||||
type: TransactionType,
|
||||
@@ -384,7 +384,7 @@ async function attach(
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOwnTransaction(
|
||||
export async function loadOwnTransaction(
|
||||
ctx: Ctx,
|
||||
transactionId: string,
|
||||
{ includeDeleted = false } = {},
|
||||
@@ -441,6 +441,8 @@ export async function updateTransaction(
|
||||
payeeId,
|
||||
updatedBy: new Types.ObjectId(actorId),
|
||||
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
|
||||
// Serinin planlı tekrarı tek başına düzenlendi ("yalnızca bu").
|
||||
...(current.recurringRuleId && current.status === "planned" ? { detached: true } : {}),
|
||||
},
|
||||
},
|
||||
{ session },
|
||||
@@ -582,7 +584,8 @@ function filterQuery(ctx: Ctx, f: TransactionFilters): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
if (f.type) q.type = f.type;
|
||||
if (f.durum) q.status = f.durum;
|
||||
// Planlı kayıtlar (12 ay ileriye kadar) yalnızca açıkça istenince listelenir (/planli sayfası).
|
||||
q.status = f.durum ?? { $ne: "planned" };
|
||||
if (f.hesap) {
|
||||
const id = toObjectId(f.hesap);
|
||||
// Gizli hesap filtresi sonuç vermez (baseFilter zaten dışlar).
|
||||
@@ -630,7 +633,7 @@ export async function listTransactions(
|
||||
{
|
||||
$match: {
|
||||
...query,
|
||||
status: query.status ?? "posted",
|
||||
status: filters.durum ?? "posted",
|
||||
type: query.type ?? { $in: ["income", "expense"] },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user