import "server-only"; import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose"; import { listOwnerAttachments, syncOwnerAttachments, type AttachmentInfo, } from "@/features/attachments/service"; import { resolveMembership } from "@/features/households/service"; import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { writeAudit } from "@/lib/audit"; import { getDb } from "@/lib/db/mongo"; import { toObjectId } from "@/lib/object-id"; import { AccountModel } from "@/models/account"; import { AssetModel } from "@/models/asset"; import { ContactModel } from "@/models/contact"; import { CategoryModel } from "@/models/category"; import { PayeeModel } from "@/models/payee"; import { TransactionModel } from "@/models/transaction"; import type { AccountType } from "@/shared/accounts"; import type { ContactEntryKind } from "@/shared/contacts"; import { allowedAttachmentKinds } from "@/shared/attachments"; import type { IconKey, Swatch } from "@/shared/appearance"; import { installmentSchedule } from "@/shared/card-periods"; import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; import type { CategoryKind } from "@/shared/schemas/category"; import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction"; import { compareTr, foldTr } from "@/shared/search"; import { normalizeTags, type TransactionStatus, type TransactionType } from "@/shared/transactions"; /** * İşlem servisi (Bölüm 6.3). Okuma: viewer; yazma: member. * Başkasının "yalnızca ben" hesabına dokunan işlemler o kullanıcıdan tamamen gizlenir * (işlemler bakiyeyi ele verir) ve bu hesaplara yalnızca hesap sahibi işlem girebilir. */ const t = tr.transactions; export const PAGE_SIZE = 25; const DAY_MS = 24 * 60 * 60 * 1000; // --- Hane bağlamı: hesaplar, kategoriler, görünürlük --- type AccountInfo = { id: Types.ObjectId; name: string; type: AccountType; archived: boolean; hidden: boolean; icon: IconKey; color: Swatch; }; type CategoryInfo = { id: Types.ObjectId; kind: CategoryKind; name: string; path: string; archived: boolean; icon: IconKey; color: Swatch; ancestors: string[]; }; /** Hane bağlamı (hesap/kategori haritaları, gizli hesaplar). İşlemler modülü içinde paylaşılır. */ export async function loadContext( userId: string, householdId: string, minRole: "viewer" | "member", ) { const m = await resolveMembership(userId, householdId, minRole); if (!m) return null; await postDueInstallments(m.householdId); const [accounts, categories, contacts, assets] = await Promise.all([ AccountModel.find({ householdId: m.householdId }) .select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 }) .lean(), CategoryModel.find({ householdId: m.householdId }) .select({ name: 1, kind: 1, parentId: 1, order: 1, archivedAt: 1, icon: 1, color: 1, ancestors: 1, }) .lean(), ContactModel.find({ householdId: m.householdId }) .select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 }) .lean(), AssetModel.find({ householdId: m.householdId }) .select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 }) .lean(), ]); const accountMap = new Map( accounts.map((a) => [ String(a._id), { id: a._id, name: a.name, type: a.type, archived: Boolean(a.archivedAt), hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId, icon: a.icon as IconKey, color: a.color as Swatch, }, ]), ); const flat = categories.map((c) => ({ id: String(c._id), kind: c.kind, name: c.name, parentId: c.parentId ? String(c.parentId) : null, order: c.order, archived: Boolean(c.archivedAt), })); const paths = new Map( toOptions(buildTree(flat), { includeArchived: true }).map((o) => [o.id, o.path]), ); const categoryMap = new Map( categories.map((c) => [ String(c._id), { id: c._id, kind: c.kind, name: c.name, path: paths.get(String(c._id)) ?? c.name, archived: Boolean(c.archivedAt), icon: c.icon as IconKey, color: c.color as Swatch, ancestors: c.ancestors.map(String), }, ]), ); const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id); const contactMap = new Map( contacts.map((c) => [ String(c._id), { id: c._id, name: c.name, archived: Boolean(c.archivedAt), hidden: Boolean(c.isPrivate) && String(c.ownerUserId) !== userId, }, ]), ); const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id); const assetMap = new Map( assets.map((a) => [ String(a._id), { id: a._id, name: a.name, assetKey: a.assetKey, deleted: Boolean(a.deletedAt), hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId, }, ]), ); const hiddenAssetIds = [...assetMap.values()].filter((a) => a.hidden).map((a) => a.id); return { m, accountMap, categoryMap, contactMap, hiddenAccountIds, hiddenContactIds, assetMap, hiddenAssetIds, flatCategories: flat, }; } export type Ctx = NonNullable>>; /** Gizli hesaplara, gizli carilere veya gizli varlıklara dokunan işlemleri dışlayan temel filtre. */ export function baseFilter(ctx: Ctx): Record { const f: Record = { householdId: ctx.m.householdId, deletedAt: null }; if (ctx.hiddenAccountIds.length) { f.accountId = { $nin: ctx.hiddenAccountIds }; f.toAccountId = { $nin: ctx.hiddenAccountIds }; } if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds }; if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds }; return f; } // --- Bakiye --- /** * Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler. * transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +. */ /** * Vadesi gelen taksitleri gerçekleştirir (planlı → posted). Okuma yollarında çağrılır; zamanlanmış * görev gerekmez. Taksitler onay beklemez (Faz 7 kararı: kendi döneminde otomatik). */ export async function postDueInstallments(householdId: Types.ObjectId, today = todayIstanbul()) { const tomorrow = istanbulDayStart(today)!.getTime() + DAY_MS; await TransactionModel.updateMany( { householdId, status: "planned", deletedAt: null, "installment.planId": { $type: "objectId" }, date: { $lt: new Date(tomorrow) }, }, { $set: { status: "posted" } }, ); } export async function computeAccountDeltas( householdId: Types.ObjectId, ): Promise> { await postDueInstallments(householdId); const pipeline: PipelineStage[] = [ { $match: { householdId, status: "posted", deletedAt: null } }, { $project: { effects: [ { a: "$accountId", d: { $cond: [ { $eq: ["$type", "income"] }, "$amountMinor", { $multiply: ["$amountMinor", -1] }, ], }, }, { a: { $cond: [{ $eq: ["$type", "transfer"] }, "$toAccountId", null] }, d: "$amountMinor", }, ], }, }, { $unwind: "$effects" }, { $match: { "effects.a": { $ne: null } } }, { $group: { _id: "$effects.a", delta: { $sum: "$effects.d" } } }, ]; const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number }>(pipeline); return new Map(rows.map((r) => [String(r._id), r.delta])); } // --- Doğrulama --- function checkAccount(ctx: Ctx, id: string | null, keep?: string | null): Types.ObjectId | null { if (!id) return null; const a = ctx.accountMap.get(id); // Düzenlemede işlemin zaten bağlı olduğu arşivli hesap korunabilir. if (!a || a.hidden || (a.archived && id !== keep)) return null; return a.id; } function checkCategory( ctx: Ctx, id: string, kind: CategoryKind, keep: Set, ): Types.ObjectId | null { const c = ctx.categoryMap.get(id); if (!c || c.kind !== kind || (c.archived && !keep.has(id))) return null; return c.id; } type Existing = { accountId?: Types.ObjectId | null; toAccountId?: Types.ObjectId | null; categoryId?: Types.ObjectId | null; splits?: { categoryId: Types.ObjectId }[]; }; export function buildDoc( ctx: Ctx, input: TransactionForm, existing?: Existing, ): ActionResult> { const date = istanbulDayStart(input.date); if (!date) return fail(tr.validation.date, { date: tr.validation.date }); const accountId = checkAccount( ctx, input.accountId, existing?.accountId ? String(existing.accountId) : null, ); if (!accountId) return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid }); let toAccountId: Types.ObjectId | null = null; let categoryId: Types.ObjectId | null = null; let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = []; if (input.type === "transfer") { toAccountId = checkAccount( ctx, input.toAccountId, existing?.toAccountId ? String(existing.toAccountId) : null, ); if (!toAccountId || toAccountId.equals(accountId)) { return fail(t.validation.accountInvalid, { toAccountId: t.validation.accountInvalid }); } } else { const kind: CategoryKind = input.type; const keep = new Set( [existing?.categoryId, ...(existing?.splits ?? []).map((s) => s.categoryId)] .filter(Boolean) .map(String), ); if (input.splits.length >= 2) { for (const [i, s] of input.splits.entries()) { const cid = checkCategory(ctx, s.categoryId, kind, keep); if (!cid) return fail(t.validation.categoryInvalid, { [`splits.${i}.categoryId`]: t.validation.categoryInvalid, }); splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note }); } } else { categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null; if (!categoryId) return fail(t.validation.categoryInvalid, { categoryId: t.validation.categoryInvalid }); splits = []; } } const effective = [ ...new Set([categoryId, ...splits.map((s) => s.categoryId)].filter(Boolean).map(String)), ].map((id) => new Types.ObjectId(id)); const payeeName = input.payee.trim(); return ok({ type: input.type, date, accountId, toAccountId, amountMinor: input.amount, categoryId, splits, effectiveCategoryIds: effective, payeeName, description: input.description, searchText: foldTr([payeeName, input.description, ...splits.map((s) => s.note)].join(" ")), tags: normalizeTags(input.tags), }); } /** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */ export async function touchPayee( householdId: Types.ObjectId, name: string, type: TransactionType, accountId: Types.ObjectId, categoryId: Types.ObjectId | null, ): Promise { if (!name) return null; const doc = await PayeeModel.findOneAndUpdate( { householdId, nameKey: foldTr(name) }, { $setOnInsert: { householdId, nameKey: foldTr(name) }, $set: { name, lastType: type, lastAccountId: accountId, lastCategoryId: categoryId, lastUsedAt: new Date(), }, $inc: { useCount: 1 }, }, { upsert: true, returnDocument: "after" }, ).lean(); return doc?._id ?? null; } // --- Yazma --- export async function createTransaction( actorId: string, householdId: string, input: TransactionForm, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const built = buildDoc(ctx, input); if (!built.ok) return built; const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null }; if (input.installments > 1 && ctx.accountMap.get(String(d.accountId))?.type !== "credit_card") { return fail(t.validation.installmentsCardOnly, { installments: t.validation.installmentsCardOnly, }); } // Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar. const payeeId = await touchPayee( ctx.m.householdId, input.payee, input.type, d.accountId, d.categoryId, ); const actor = new Types.ObjectId(actorId); try { let id = ""; await mongoose.connection.transaction(async (session) => { const base = { ...built.data, householdId: ctx.m.householdId, status: "posted", payeeId, createdBy: actor, updatedBy: actor, }; // Taksitli: her taksit ayrı işlem; vadesi gelmeyenler planlı (kendi döneminde gerçekleşir). const today = todayIstanbul(); const planId = new Types.ObjectId(); const docs = input.installments > 1 ? installmentSchedule(input.date, input.amount, input.installments).map((x) => ({ ...base, amountMinor: x.amountMinor, date: istanbulDayStart(x.date)!, status: x.date <= today ? "posted" : "planned", installment: { planId, index: x.index + 1, count: input.installments }, })) : [base]; const created = await TransactionModel.insertMany(docs as Record[], { session, }); const first = created[0]!; id = String(first._id); await attach(ctx, actorId, first._id, input, session); }); return ok({ id }); } catch (error) { if (error instanceof AttachmentError) return error.result; throw error; } } /** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */ class AttachmentError extends Error { constructor(readonly result: ActionResult) { super(result.ok ? "" : result.error); } } async function attach( ctx: Ctx, actorId: string, transactionId: Types.ObjectId, input: TransactionForm, session: ClientSession, ) { const synced = await syncOwnerAttachments({ householdId: ctx.m.householdId, actorId, ownerType: "transaction", ownerId: transactionId, ids: input.attachmentIds, allowedKinds: allowedAttachmentKinds(input.type), session, }); if (!synced.ok) throw new AttachmentError(synced); await TransactionModel.updateOne( { _id: transactionId, householdId: ctx.m.householdId }, { $set: { attachmentCount: synced.data } }, { session }, ); } export async function loadOwnTransaction( ctx: Ctx, transactionId: string, { includeDeleted = false } = {}, ) { const id = toObjectId(transactionId); if (!id) return null; const filter: Record = { ...baseFilter(ctx), _id: id }; if (includeDeleted) delete filter.deletedAt; return TransactionModel.findOne(filter).lean(); } export async function updateTransaction( actorId: string, householdId: string, transactionId: string, input: TransactionForm, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const current = await loadOwnTransaction(ctx, transactionId); if (!current) return fail(tr.common.notFound); if (current.isAdjustment) return fail(t.adjustmentReadOnly); // Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü). if (current.contactId) return fail(t.contactEditHint); if (current.assetId) return fail(t.assetLocked); const built = buildDoc(ctx, input, current); if (!built.ok) return built; const d = built.data as { type: TransactionType; date: Date; amountMinor: number; accountId: Types.ObjectId; toAccountId: Types.ObjectId | null; categoryId: Types.ObjectId | null; }; const payeeId = await touchPayee( ctx.m.householdId, input.payee, input.type, d.accountId, d.categoryId, ); // Bakiyeyi etkileyen bir alan değiştiyse mutabakat işareti geçersizleşir. const balanceChanged = d.type !== current.type || d.amountMinor !== current.amountMinor || d.date.getTime() !== new Date(current.date).getTime() || String(d.accountId) !== String(current.accountId ?? "") || String(d.toAccountId ?? "") !== String(current.toAccountId ?? ""); try { await mongoose.connection.transaction(async (session) => { await TransactionModel.updateOne( { _id: current._id, householdId: ctx.m.householdId }, { $set: { ...built.data, 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 }, ); await attach(ctx, actorId, current._id, input, session); }); } catch (error) { if (error instanceof AttachmentError) return error.result; throw error; } return ok(undefined); } export async function setTransactionDeleted( actorId: string, householdId: string, transactionId: string, deleted: boolean, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true }); if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound); // Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez. if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked); // Varlık alım/satışı hareketin parçasıdır; hareket Birikim sayfasından silinir. if (current.assetId) return fail(t.assetLocked); const actor = new Types.ObjectId(actorId); await TransactionModel.updateOne( { _id: current._id, householdId: ctx.m.householdId }, { $set: { deletedAt: deleted ? new Date() : null, updatedBy: actor } }, ); await writeAudit({ action: deleted ? "transaction.delete" : "transaction.restore", actorId: actor, householdId: ctx.m.householdId, targetType: "transaction", targetId: transactionId, diff: { type: current.type, amountMinor: current.amountMinor, date: toIstanbulYmd(current.date), }, }); return ok(undefined); } /** * Kalan (henüz gerçekleşmemiş) taksitleri iptal eder — ör. ürün iadesi veya erken kapama. * Gerçekleşmiş taksitlere dokunulmaz. */ export async function cancelRemainingInstallments( actorId: string, householdId: string, transactionId: string, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const tx = await loadOwnTransaction(ctx, transactionId); const planId = tx?.installment?.planId; if (!tx || !planId) return fail(tr.common.notFound); const actor = new Types.ObjectId(actorId); const res = await TransactionModel.updateMany( { householdId: ctx.m.householdId, "installment.planId": planId, status: "planned", deletedAt: null, }, { $set: { deletedAt: new Date(), updatedBy: actor } }, ); await writeAudit({ action: "transaction.delete", actorId: actor, householdId: ctx.m.householdId, targetType: "installmentPlan", targetId: String(planId), diff: { cancelled: res.modifiedCount, count: tx.installment?.count ?? null }, }); return ok({ cancelled: res.modifiedCount }); } // --- Okuma --- export type TransactionListItem = { id: string; type: TransactionType; status: TransactionStatus; date: string; amountMinor: number; /** Kaynak hesap; cariden girişte (borç alma, tahsilat) null. */ account: { id: string; name: string } | null; toAccount: { id: string; name: string } | null; /** Cari transferi: kişi ve hareket türü. */ contact: { id: string; name: string; kind: ContactEntryKind } | null; /** Varlık alımı/satışı: karşı taraf varlık. */ asset: { id: string; name: string } | null; category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null; splitCount: number; payeeName: string; description: string; tags: string[]; createdBy: { id: string; name: string }; attachmentCount: number; /** Mutabakat düzeltmesi (kategorisiz, düzenlenemez). */ isAdjustment: boolean; /** Mutabakatı yapılmış hesaplar. */ reconciledAccountIds: string[]; /** Taksit: "4/9" */ installment: { planId: string; index: number; count: number } | null; }; export type TransactionDetail = TransactionListItem & { splits: { categoryId: string; path: string; icon: IconKey; color: Swatch; amountMinor: number; note: string; }[]; updatedBy: { id: string; name: string }; createdAt: string; updatedAt: string; /** Düzenleme formu için ham kimlikler. */ categoryId: string | null; attachments: AttachmentInfo[]; }; export async function userNames(ids: Types.ObjectId[]): Promise> { const unique = [...new Set(ids.map(String))].map((id) => new Types.ObjectId(id)); const users = await getDb() .collection("user") .find({ _id: { $in: unique } }, { projection: { name: 1 } }) .toArray(); return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")])); } export type LeanTx = Awaited>[number] & { _id: Types.ObjectId; createdAt: Date; updatedAt: Date; }; export function toItem(ctx: Ctx, tx: LeanTx, names: Map): TransactionListItem { const acc = (id: Types.ObjectId | null | undefined) => { if (!id) return null; const a = ctx.accountMap.get(String(id)); return { id: String(id), name: a?.name ?? "—" }; }; const cat = tx.categoryId ? ctx.categoryMap.get(String(tx.categoryId)) : undefined; return { id: String(tx._id), type: tx.type, status: tx.status, date: toIstanbulYmd(tx.date), amountMinor: tx.amountMinor, account: acc(tx.accountId), toAccount: acc(tx.toAccountId), category: cat ? { id: String(cat.id), name: cat.name, path: cat.path, icon: cat.icon, color: cat.color } : null, splitCount: tx.splits?.length ?? 0, payeeName: tx.payeeName ?? "", description: tx.description ?? "", tags: tx.tags ?? [], createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" }, attachmentCount: tx.attachmentCount ?? 0, isAdjustment: Boolean(tx.isAdjustment), reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String), contact: tx.contactId && tx.contactKind ? { id: String(tx.contactId), name: ctx.contactMap.get(String(tx.contactId))?.name ?? "—", kind: tx.contactKind, } : null, asset: tx.assetId ? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" } : null, installment: tx.installment?.planId ? { planId: String(tx.installment.planId), index: tx.installment.index ?? 0, count: tx.installment.count ?? 0, } : null, }; } const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); function filterQuery(ctx: Ctx, f: TransactionFilters): Record { const q: Record = baseFilter(ctx); const and: Record[] = []; const from = f.from ? istanbulDayStart(f.from) : null; const to = f.to ? istanbulDayStart(f.to) : null; if (from || to) { q.date = { ...(from ? { $gte: from } : {}), ...(to ? { $lt: new Date(to.getTime() + DAY_MS) } : {}), }; } if (f.type) q.type = f.type; // 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). and.push({ $or: [{ accountId: id }, { toAccountId: id }] }); } if (f.kategori) { // Alt kategoriler dahil. const ids = [...ctx.categoryMap.values()] .filter((c) => String(c.id) === f.kategori || c.ancestors.includes(f.kategori!)) .map((c) => c.id); q.effectiveCategoryIds = { $in: ids }; } if (f.kisi) q.createdBy = toObjectId(f.kisi); if (f.min !== undefined || f.max !== undefined) { q.amountMinor = { ...(f.min !== undefined ? { $gte: f.min } : {}), ...(f.max !== undefined ? { $lte: f.max } : {}), }; } if (f.etiket) q.tags = normalizeTags(f.etiket)[0] ?? ""; if (f.q) q.searchText = { $regex: escapeRegex(foldTr(f.q)) }; if (and.length) q.$and = and; return q; } export type TransactionPage = { items: TransactionListItem[]; total: number; page: number; pageCount: number; sums: { incomeMinor: number; expenseMinor: number }; }; export async function listTransactions( userId: string, householdId: string, filters: TransactionFilters, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const query = filterQuery(ctx, filters); const [total, sumRows] = await Promise.all([ TransactionModel.countDocuments(query), TransactionModel.aggregate<{ _id: TransactionType; sum: number }>([ { $match: { ...query, status: filters.durum ?? "posted", type: query.type ?? { $in: ["income", "expense"] }, }, }, { $group: { _id: "$type", sum: { $sum: "$amountMinor" } } }, ]), ]); const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const page = Math.min(filters.sayfa, pageCount); const rows = (await TransactionModel.find(query) .sort({ date: -1, createdAt: -1 }) .skip((page - 1) * PAGE_SIZE) .limit(PAGE_SIZE) .lean()) as unknown as LeanTx[]; const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId)); return { items: rows.map((r) => toItem(ctx, r, names)), total, page, pageCount, sums: { incomeMinor: sumRows.find((r) => r._id === "income")?.sum ?? 0, expenseMinor: sumRows.find((r) => r._id === "expense")?.sum ?? 0, }, }; } export async function getTransaction( userId: string, householdId: string, transactionId: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const tx = (await loadOwnTransaction(ctx, transactionId)) as unknown as LeanTx | null; if (!tx) return null; const [names, attachments] = await Promise.all([ userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]), tx.attachmentCount ? listOwnerAttachments(ctx.m.householdId, "transaction", tx._id) : Promise.resolve([]), ]); return { attachments, ...toItem(ctx, tx, names), categoryId: tx.categoryId ? String(tx.categoryId) : null, splits: (tx.splits ?? []).map((s) => { const c = ctx.categoryMap.get(String(s.categoryId)); return { categoryId: String(s.categoryId), path: c?.path ?? "—", icon: c?.icon ?? "tag", color: c?.color ?? "slate", amountMinor: s.amountMinor, note: s.note ?? "", }; }), updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" }, createdAt: new Date(tx.createdAt).toISOString(), updatedAt: new Date(tx.updatedAt).toISOString(), }; } /** CSV için tüm eşleşen işlemler (sayfalama yok; üst sınır 20.000). Bölünmüş işlem kalem başına satır. */ export async function exportTransactions( userId: string, householdId: string, filters: TransactionFilters, ) { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const rows = await TransactionModel.find(filterQuery(ctx, filters)) .sort({ date: -1, createdAt: -1 }) .limit(20_000) .lean(); const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId)); const accountName = (id: Types.ObjectId | null | undefined) => id ? (ctx.accountMap.get(String(id))?.name ?? "") : ""; const categoryPath = (id: Types.ObjectId | null | undefined) => id ? (ctx.categoryMap.get(String(id))?.path ?? "") : ""; const sign = (type: TransactionType) => (type === "income" ? 1 : type === "expense" ? -1 : 1); const out: { no: string; date: string; type: TransactionType; status: TransactionStatus; account: string; toAccount: string; category: string; payee: string; description: string; note: string; tags: string; amountMinor: number; createdBy: string; }[] = []; for (const r of rows) { const common = { no: String(r._id), date: toIstanbulYmd(r.date), type: r.type, status: r.status, account: accountName(r.accountId), toAccount: accountName(r.toAccountId), payee: r.payeeName ?? "", description: r.description ?? "", tags: (r.tags ?? []).join(", "), createdBy: names.get(String(r.createdBy)) ?? "", }; if (r.splits?.length) { for (const s of r.splits) { out.push({ ...common, category: categoryPath(s.categoryId), note: s.note ?? "", amountMinor: sign(r.type) * s.amountMinor, }); } } else { out.push({ ...common, category: r.isAdjustment ? t.adjustment : r.contactId && r.contactKind ? `${tr.contacts.contact}: ${ctx.contactMap.get(String(r.contactId))?.name ?? ""} (${tr.contacts.kinds[r.contactKind]})` : categoryPath(r.categoryId), note: "", amountMinor: sign(r.type) * r.amountMinor, }); } } return out; } // --- Form seçenekleri ve satıcı önerileri --- export type TransactionFormOptions = { accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[]; categories: CategoryOption[]; categoryAppearance: Record; }; export async function getTransactionFormOptions( userId: string, householdId: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const accounts = ( await AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null }) .select({ name: 1, type: 1, icon: 1, color: 1, order: 1, isPrivate: 1, ownerUserId: 1 }) .lean() ) .filter((a) => !a.isPrivate || String(a.ownerUserId) === userId) .sort((a, b) => a.order - b.order || compareTr(a.name, b.name)) .map((a) => ({ id: String(a._id), name: a.name, type: a.type, icon: a.icon as IconKey, color: a.color as Swatch, })); const categories = toOptions(buildTree(ctx.flatCategories)); const categoryAppearance = Object.fromEntries( [...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]), ); return { accounts, categories, categoryAppearance }; } export type PayeeSuggestion = { name: string; lastType: TransactionType; lastCategoryId: string | null; lastAccountId: string | null; }; /** Satıcı önerileri: ada göre (Türkçe normalize, baştan veya içinden), kullanım sıklığına göre. */ export async function searchPayees( userId: string, householdId: string, query: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return []; const key = foldTr(query); const filter: Record = { householdId: ctx.m.householdId }; if (key) filter.nameKey = { $regex: escapeRegex(key) }; const rows = await PayeeModel.find(filter).sort({ useCount: -1, lastUsedAt: -1 }).limit(8).lean(); return rows.map((p) => { const acc = p.lastAccountId ? ctx.accountMap.get(String(p.lastAccountId)) : undefined; const cat = p.lastCategoryId ? ctx.categoryMap.get(String(p.lastCategoryId)) : undefined; return { name: p.name, lastType: p.lastType, // Öneri yalnızca hâlâ kullanılabilir hesap/kategoriyi gösterir. lastAccountId: acc && !acc.hidden && !acc.archived ? String(acc.id) : null, lastCategoryId: cat && !cat.archived ? String(cat.id) : null, }; }); }