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

914 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only";
import mongoose, { Types, type ClientSession, 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 { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
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] = 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(),
]);
const accountMap = new Map<string, AccountInfo>(
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<string, CategoryInfo>(
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);
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
}
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
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 };
f.toAccountId = { $nin: ctx.hiddenAccountIds };
}
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<Map<string, number>> {
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<string>,
): 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;
toAccountId?: Types.ObjectId | null;
categoryId?: Types.ObjectId | null;
splits?: { categoryId: Types.ObjectId }[];
};
export function buildDoc(
ctx: Ctx,
input: TransactionForm,
existing?: Existing,
): ActionResult<Record<string, unknown>> {
const date = istanbulDayStart(input.date);
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
const accountId = checkAccount(
ctx,
input.accountId,
existing ? 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<Types.ObjectId | null> {
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<ActionResult<{ id: string }>> {
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<string, unknown>[], {
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<never>) {
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<string, unknown> = { ...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<ActionResult> {
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);
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() ||
!d.accountId.equals(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<ActionResult> {
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);
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<ActionResult<{ cancelled: number }>> {
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;
account: { id: string; name: string };
toAccount: { 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<Map<string, string>> {
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<ReturnType<typeof TransactionModel.find>>[number] & {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
};
export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): 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),
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<string, unknown> {
const q: Record<string, unknown> = baseFilter(ctx);
const and: Record<string, unknown>[] = [];
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<TransactionPage | null> {
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<TransactionDetail | null> {
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 : 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<string, { icon: IconKey; color: Swatch }>;
};
export async function getTransactionFormOptions(
userId: string,
householdId: string,
): Promise<TransactionFormOptions | null> {
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<PayeeSuggestion[]> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return [];
const key = foldTr(query);
const filter: Record<string, unknown> = { 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,
};
});
}