import "server-only"; import { Types } from "mongoose"; import { getHouseholdDetail, resolveMembership } from "@/features/households/service"; import { computeAccountDeltas } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { diffFields, writeAudit } from "@/lib/audit"; import { toObjectId } from "@/lib/object-id"; import { AccountModel } from "@/models/account"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { isLiabilityType, type AccountType, type DueRuleKind, type MealCardProvider, } from "@/shared/accounts"; import type { IconKey, Swatch } from "@/shared/appearance"; import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates"; import type { AccountForm } from "@/shared/schemas/account"; import { compareTr } from "@/shared/search"; /** * Hesap servisi. Okuma: viewer; oluşturma/düzenleme/arşiv/sıralama: owner (Bölüm 5). * "Yalnızca ben" hesaplarında bakiye yalnızca hesap sahibi kişiye görünür. * Bakiye işareti: varlık pozitif, kredi kartı borcu negatif. */ const t = tr.accounts; export type AccountView = { id: string; name: string; type: AccountType; institution: string; mealCardProvider: MealCardProvider | null; color: Swatch; icon: IconKey; order: number; archived: boolean; isPrivate: boolean; ownerUserId: string | null; /** Bu kullanıcıya gizliyse null. */ balanceMinor: number | null; openingBalanceMinor: number | null; openingDate: string; overdraft: { enabled: boolean; limitMinor: number; monthlyRateBp: number | null; statementDay: number | null; } | null; creditCard: { limitMinor: number; statementDay: number; dueRule: { kind: DueRuleKind; value: number }; paymentAccountId: string | null; minimumPaymentRateBp: number | null; } | null; }; export type AccountTotals = { /** Nakit + banka + birikim + yemek kartı (pozitif bakiyeler). */ assetsMinor: number; /** Kredi kartı borçları (negatif). */ cardDebtMinor: number; netMinor: number; /** Toplamlarda bu kullanıcının bakiyesini göremediği hesap var mı? */ includesHidden: boolean; /** Gizli hesaplar hane ayarı gereği toplamlardan çıkarıldı mı? */ excludesHidden: boolean; }; type Lean = { _id: Types.ObjectId; name: string; type: AccountType; institution: string; mealCardProvider: MealCardProvider | null; color: Swatch; icon: IconKey; order: number; archivedAt: Date | null; isPrivate: boolean; ownerUserId: Types.ObjectId | null; openingBalanceMinor: number; openingDate: Date; overdraft: AccountView["overdraft"]; creditCard: | (Omit, "paymentAccountId"> & { paymentAccountId: Types.ObjectId | null; }) | null; }; const canSeeBalance = (a: Lean, viewerId: string) => !a.isPrivate || String(a.ownerUserId) === viewerId; /** Güncel bakiye = açılış bakiyesi + gerçekleşmiş işlemlerin etkisi (tek doğruluk kaynağı: işlemler). */ function computeBalance(a: Lean, deltas: ReadonlyMap): number { return a.openingBalanceMinor + (deltas.get(String(a._id)) ?? 0); } function toView(a: Lean, viewerId: string, deltas: ReadonlyMap): AccountView { const visible = canSeeBalance(a, viewerId); return { id: String(a._id), name: a.name, type: a.type, institution: a.institution ?? "", mealCardProvider: a.mealCardProvider ?? null, color: a.color, icon: a.icon, order: a.order, archived: Boolean(a.archivedAt), isPrivate: a.isPrivate, ownerUserId: a.ownerUserId ? String(a.ownerUserId) : null, balanceMinor: visible ? computeBalance(a, deltas) : null, openingBalanceMinor: visible ? a.openingBalanceMinor : null, openingDate: toIstanbulYmd(a.openingDate), overdraft: a.overdraft?.enabled ? a.overdraft : null, creditCard: a.creditCard ? { ...a.creditCard, paymentAccountId: a.creditCard.paymentAccountId ? String(a.creditCard.paymentAccountId) : null, } : null, }; } export async function listAccounts( userId: string, householdId: string, opts: { includeArchived?: boolean } = {}, ): Promise<{ accounts: AccountView[]; totals: AccountTotals } | null> { const m = await resolveMembership(userId, householdId, "viewer"); if (!m) return null; const filter: Record = { householdId: m.householdId }; if (!opts.includeArchived) filter.archivedAt = null; const rows = (await AccountModel.find(filter).lean()).sort( (a, b) => a.order - b.order || compareTr(a.name, b.name), ); const [household, deltas] = await Promise.all([ HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(), computeAccountDeltas(m.householdId), ]); const includePrivate = household?.settings?.includePrivateAccountsInTotals ?? true; const totals: AccountTotals = { assetsMinor: 0, cardDebtMinor: 0, netMinor: 0, includesHidden: false, excludesHidden: false, }; for (const a of rows) { if (a.archivedAt) continue; const visible = canSeeBalance(a, userId); if (!visible && !includePrivate) { totals.excludesHidden = true; continue; } if (!visible) totals.includesHidden = true; const balance = computeBalance(a, deltas); if (isLiabilityType(a.type)) totals.cardDebtMinor += balance; else totals.assetsMinor += balance; } totals.netMinor = totals.assetsMinor + totals.cardDebtMinor; return { accounts: rows.map((a) => toView(a, userId, deltas)), totals }; } /** Form → belge alanları. Kart için açılış borcu negatif saklanır. */ async function toDocFields( householdId: Types.ObjectId, input: AccountForm, selfId?: Types.ObjectId, ): Promise>> { const openingDate = istanbulDayStart(input.openingDate); if (!openingDate) return fail(tr.validation.date, { openingDate: tr.validation.date }); let ownerUserId: Types.ObjectId | null = null; if (input.ownerUserId) { ownerUserId = toObjectId(input.ownerUserId); const isMember = ownerUserId && (await HouseholdMemberModel.exists({ householdId, userId: ownerUserId })); if (!isMember) return fail(t.ownerNotMember, { ownerUserId: t.ownerNotMember }); } let paymentAccountId: Types.ObjectId | null = null; if (input.type === "credit_card" && input.cardPaymentAccountId) { paymentAccountId = toObjectId(input.cardPaymentAccountId); const payer = paymentAccountId && !paymentAccountId.equals(selfId ?? new Types.ObjectId()) && (await AccountModel.exists({ _id: paymentAccountId, householdId, archivedAt: null, type: { $in: ["bank", "cash", "savings"] }, })); if (!payer) return fail(t.invalidPaymentAccount, { cardPaymentAccountId: t.invalidPaymentAccount }); } const isCard = input.type === "credit_card"; return ok({ name: input.name, institution: input.institution, mealCardProvider: input.type === "meal_card" ? input.mealCardProvider : null, openingBalanceMinor: isCard ? -Math.abs(input.openingBalance) : input.openingBalance, openingDate, color: input.color, icon: input.icon, ownerUserId, isPrivate: input.isPrivate, overdraft: input.type === "bank" && input.overdraftEnabled ? { enabled: true, limitMinor: input.overdraftLimit, monthlyRateBp: input.overdraftMonthlyRate, statementDay: input.overdraftStatementDay, } : null, creditCard: isCard ? { limitMinor: input.cardLimit, statementDay: input.cardStatementDay, dueRule: { kind: input.cardDueKind, value: input.cardDueValue }, paymentAccountId, minimumPaymentRateBp: input.cardMinimumRate, } : null, }); } export async function createAccount( actorId: string, householdId: string, input: AccountForm, ): Promise> { const m = await resolveMembership(actorId, householdId, "owner"); if (!m) return fail(tr.common.unauthorized); const fields = await toDocFields(m.householdId, input); if (!fields.ok) return fields; const last = await AccountModel.findOne({ householdId: m.householdId }) .sort({ order: -1 }) .select({ order: 1 }) .lean(); const actor = new Types.ObjectId(actorId); const doc = await AccountModel.create({ ...fields.data, householdId: m.householdId, type: input.type, order: (last?.order ?? -1) + 1, createdBy: actor, updatedBy: actor, }); await writeAudit({ action: "account.create", actorId: actor, householdId: m.householdId, targetType: "account", targetId: String(doc._id), diff: { name: input.name, type: input.type }, }); return ok({ id: String(doc._id) }); } export async function updateAccount( actorId: string, householdId: string, accountId: string, input: AccountForm, ): Promise { const m = await resolveMembership(actorId, householdId, "owner"); if (!m) return fail(tr.common.unauthorized); const id = toObjectId(accountId); const current = id ? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean() : null; if (!current) return fail(tr.common.notFound); // Tür değişikliği desteklenmez (işlemler ve kart dönemleri türe bağlıdır). if (current.type !== input.type) return fail(t.typeLocked, { type: t.typeLocked }); const fields = await toDocFields(m.householdId, input, current._id); if (!fields.ok) return fields; // Bakiyesini göremediği gizli hesapta açılış bakiyesi korunur (ne görünür ne değişir). if (!canSeeBalance(current, actorId)) delete fields.data.openingBalanceMinor; const actor = new Types.ObjectId(actorId); await AccountModel.updateOne( { _id: current._id, householdId: m.householdId }, { $set: { ...fields.data, updatedBy: actor } }, ); await writeAudit({ action: "account.update", actorId: actor, householdId: m.householdId, targetType: "account", targetId: accountId, // Gizli hesabın bakiyesi denetim kaydına yazılmaz. diff: diffFields( { name: current.name, institution: current.institution, isPrivate: current.isPrivate, ownerUserId: String(current.ownerUserId ?? ""), }, { name: input.name, institution: input.institution, isPrivate: input.isPrivate, ownerUserId: input.ownerUserId ?? "", }, ), }); return ok(undefined); } export async function setAccountArchived( actorId: string, householdId: string, accountId: string, archived: boolean, ): Promise { const m = await resolveMembership(actorId, householdId, "owner"); if (!m) return fail(tr.common.unauthorized); const id = toObjectId(accountId); const current = id ? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean() : null; if (!current) return fail(tr.common.notFound); const actor = new Types.ObjectId(actorId); await AccountModel.updateOne( { _id: current._id, householdId: m.householdId }, { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, ); await writeAudit({ action: archived ? "account.archive" : "account.unarchive", actorId: actor, householdId: m.householdId, targetType: "account", targetId: accountId, diff: { name: current.name }, }); return ok(undefined); } export async function reorderAccount( actorId: string, householdId: string, accountId: string, direction: "up" | "down", ): Promise { const m = await resolveMembership(actorId, householdId, "owner"); if (!m) return fail(tr.common.unauthorized); const list = ( await AccountModel.find({ householdId: m.householdId, archivedAt: null }).lean() ).sort((a, b) => a.order - b.order || compareTr(a.name, b.name)); const i = list.findIndex((a) => String(a._id) === accountId); const j = direction === "up" ? i - 1 : i + 1; if (i < 0 || j < 0 || j >= list.length) return ok(undefined); [list[i], list[j]] = [list[j]!, list[i]!]; await AccountModel.bulkWrite( list.map((a, order) => ({ updateOne: { filter: { _id: a._id, householdId: m.householdId }, update: { $set: { order } }, }, })), ); return ok(undefined); } /** Hane üyeleri (hesap sahibi seçimi için). */ export async function listMemberOptions(userId: string, householdId: string) { const detail = await getHouseholdDetail(userId, householdId); if (!detail) return null; return detail.members.map((x) => ({ id: x.userId, name: x.name })); }