import "server-only"; import { Types, type PipelineStage } from "mongoose"; import { loadContext, loadOwnTransaction, toItem, userNames, type Ctx, type LeanTx, type TransactionListItem, } from "@/features/transactions/service"; import { hasHouseholdRole } from "@/features/households/roles"; 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 { ContactModel } from "@/models/contact"; import { TransactionModel } from "@/models/transaction"; import { contactDelta, dueState, isOutgoing, type ContactRelation, type DueState, } from "@/shared/contacts"; import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; import { addDays } from "@/shared/recurrence"; import type { ContactEntry, ContactForm } from "@/shared/schemas/contact"; import { compareTr, foldTr } from "@/shared/search"; /** * Cari hesaplar (Bölüm 6.4). Okuma: viewer; yazma: member. Hareketler `transactions` içinde * transferdir (`contactId`, `contactKind`): cariye çıkışta `accountId` dolu/`toAccountId` boş, * cariden girişte tersi. Böylece hesap bakiyesi, ekstre ve mutabakat ek kural gerektirmez; * gelir/gider raporlarına girmez. */ const t = tr.contacts; type ContactDoc = { _id: Types.ObjectId; householdId: Types.ObjectId; name: string; relation: ContactRelation; phone: string; note: string; openingBalanceMinor: number; openingDate: Date; dueDate?: string | null; reminderDays: number; isPrivate: boolean; ownerUserId?: Types.ObjectId | null; archivedAt?: Date | null; updatedAt: Date; }; const visible = (c: ContactDoc, userId: string) => !c.isPrivate || String(c.ownerUserId) === userId; /** Cari bazında hareket toplamı (+ çıkış, − giriş) ve son hareket tarihi. */ async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.ObjectId[]) { const pipeline: PipelineStage[] = [ { $match: { householdId, status: "posted", deletedAt: null, contactId: contactIds ? { $in: contactIds } : { $type: "objectId" }, // Aynî borç hareketleri cari TL bakiyesine girmez. contactKind: { $ne: null }, }, }, { $group: { _id: "$contactId", delta: { $sum: { $cond: [ { $in: ["$contactKind", ["lend", "repay"]] }, "$amountMinor", { $multiply: ["$amountMinor", -1] }, ], }, }, last: { $max: "$date" }, }, }, ]; const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number; last: Date }>( pipeline, ); return new Map(rows.map((r) => [String(r._id), r])); } export type ContactView = { id: string; name: string; relation: ContactRelation; phone: string; note: string; /** + bana borçlu, − ben borçluyum. */ balanceMinor: number; openingBalanceMinor: number; openingDate: string; dueDate: string | null; reminderDays: number; due: DueState; isPrivate: boolean; archived: boolean; lastActivity: string | null; }; function toView( c: ContactDoc, totals: Map, today: string, ): ContactView { const x = totals.get(String(c._id)); const balanceMinor = c.openingBalanceMinor + (x?.delta ?? 0); return { id: String(c._id), name: c.name, relation: c.relation, phone: c.phone, note: c.note, balanceMinor, openingBalanceMinor: c.openingBalanceMinor, openingDate: toIstanbulYmd(c.openingDate), dueDate: c.dueDate ?? null, reminderDays: c.reminderDays, due: dueState(balanceMinor, c.dueDate ?? null, c.reminderDays, today, addDays), isPrivate: c.isPrivate, archived: Boolean(c.archivedAt), lastActivity: x?.last ? toIstanbulYmd(x.last) : null, }; } export type ContactList = { contacts: ContactView[]; totals: { owesMeMinor: number; iOweMinor: number }; }; export async function listContacts( userId: string, householdId: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const docs = (await ContactModel.find({ householdId: ctx.m.householdId }).lean()) .filter((c) => visible(c, userId)) .sort((a, b) => compareTr(a.name, b.name)); const totals = await contactTotals( ctx.m.householdId, docs.map((d) => d._id), ); const today = todayIstanbul(); const contacts = docs.map((c) => toView(c, totals, today)); let owesMeMinor = 0; let iOweMinor = 0; for (const c of contacts) { if (c.archived) continue; if (c.balanceMinor > 0) owesMeMinor += c.balanceMinor; else iOweMinor += -c.balanceMinor; } return { contacts, totals: { owesMeMinor, iOweMinor } }; } async function loadContact(ctx: Ctx, contactId: string, userId: string) { const id = toObjectId(contactId); if (!id) return null; const c = await ContactModel.findOne({ _id: id, householdId: ctx.m.householdId, }).lean(); return c && visible(c, userId) ? c : null; } async function nameTaken(householdId: Types.ObjectId, name: string, exceptId?: Types.ObjectId) { const key = foldTr(name); const others = await ContactModel.find( { householdId, archivedAt: null, ...(exceptId ? { _id: { $ne: exceptId } } : {}) }, { name: 1 }, ).lean(); return others.some((o) => foldTr(o.name) === key); } // --- Cari yazma --- export async function createContact( actorId: string, householdId: string, input: ContactForm, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); if (await nameTaken(ctx.m.householdId, input.name)) return fail(t.errors.duplicate, { name: t.errors.duplicate }); const actor = new Types.ObjectId(actorId); const doc = await ContactModel.create({ householdId: ctx.m.householdId, name: input.name, relation: input.relation, phone: input.phone, note: input.note, openingBalanceMinor: input.openingBalance, openingDate: istanbulDayStart(input.openingDate)!, dueDate: input.dueDate, reminderDays: input.reminderDays, isPrivate: input.isPrivate, ownerUserId: input.isPrivate ? actor : null, createdBy: actor, updatedBy: actor, }); return ok({ id: String(doc._id) }); } export async function updateContact( actorId: string, householdId: string, contactId: string, input: ContactForm, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const c = await loadContact(ctx, contactId, actorId); if (!c) return fail(tr.common.notFound); if (await nameTaken(ctx.m.householdId, input.name, c._id)) return fail(t.errors.duplicate, { name: t.errors.duplicate }); // Gizlilik yalnızca sahibince (veya ortak cariyi gizlerken işlemi yapanca) belirlenir. const owner = c.ownerUserId ?? (input.isPrivate ? new Types.ObjectId(actorId) : null); await ContactModel.updateOne( { _id: c._id, householdId: ctx.m.householdId }, { $set: { name: input.name, relation: input.relation, phone: input.phone, note: input.note, openingBalanceMinor: input.openingBalance, openingDate: istanbulDayStart(input.openingDate)!, dueDate: input.dueDate, reminderDays: input.reminderDays, isPrivate: input.isPrivate, ownerUserId: input.isPrivate ? owner : null, updatedBy: new Types.ObjectId(actorId), }, }, ); return ok(undefined); } export async function setContactArchived( actorId: string, householdId: string, contactId: string, archived: boolean, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const c = await loadContact(ctx, contactId, actorId); if (!c) return fail(tr.common.notFound); if (!archived && (await nameTaken(ctx.m.householdId, c.name, c._id))) return fail(t.errors.duplicate); await ContactModel.updateOne( { _id: c._id, householdId: ctx.m.householdId }, { $set: { archivedAt: archived ? new Date() : null, updatedBy: new Types.ObjectId(actorId) } }, ); await writeAudit({ action: archived ? "contact.archive" : "contact.unarchive", actorId: new Types.ObjectId(actorId), householdId: ctx.m.householdId, targetType: "contact", targetId: String(c._id), diff: { name: c.name }, }); return ok(undefined); } // --- Cari hareketleri --- function entryFields(ctx: Ctx, contact: ContactDoc, input: ContactEntry, keepAccount?: string) { const info = ctx.accountMap.get(input.accountId); if (!info || info.hidden || (info.archived && input.accountId !== keepAccount)) { return fail(tr.transactions.validation.accountInvalid, { accountId: tr.transactions.validation.accountInvalid, }); } const date = istanbulDayStart(input.date); if (!date) return fail(tr.validation.date, { date: tr.validation.date }); const out = isOutgoing(input.kind); return ok({ type: "transfer" as const, date, accountId: out ? info.id : null, toAccountId: out ? null : info.id, contactId: contact._id, contactKind: input.kind, amountMinor: input.amount, categoryId: null, splits: [], effectiveCategoryIds: [], payeeName: "", description: input.description, searchText: foldTr(`${contact.name} ${input.description}`), }); } async function applyDue(contact: ContactDoc, input: ContactEntry, actor: Types.ObjectId) { if (input.dueDate === undefined) return; await ContactModel.updateOne( { _id: contact._id }, { $set: { dueDate: input.dueDate, ...(input.reminderDays !== undefined ? { reminderDays: input.reminderDays } : {}), updatedBy: actor, }, }, ); } export async function createContactEntry( actorId: string, householdId: string, contactId: string, input: ContactEntry, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const contact = await loadContact(ctx, contactId, actorId); if (!contact) return fail(tr.common.notFound); if (contact.archivedAt) return fail(t.errors.archived); const fields = entryFields(ctx, contact, input); if (!fields.ok) return fields; const actor = new Types.ObjectId(actorId); const doc = await TransactionModel.create({ ...fields.data, householdId: ctx.m.householdId, status: "posted", createdBy: actor, updatedBy: actor, }); await applyDue(contact, input, actor); return ok({ id: String(doc._id) }); } export async function updateContactEntry( actorId: string, householdId: string, transactionId: string, input: ContactEntry, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const tx = await loadOwnTransaction(ctx, transactionId); if (!tx?.contactId) return fail(tr.common.notFound); const contact = await loadContact(ctx, String(tx.contactId), actorId); if (!contact) return fail(tr.common.notFound); const keep = String(tx.accountId ?? tx.toAccountId ?? ""); const fields = entryFields(ctx, contact, input, keep); if (!fields.ok) return fields; const d = fields.data; const balanceChanged = d.amountMinor !== tx.amountMinor || d.date.getTime() !== new Date(tx.date).getTime() || String(d.accountId ?? "") !== String(tx.accountId ?? "") || String(d.toAccountId ?? "") !== String(tx.toAccountId ?? ""); const actor = new Types.ObjectId(actorId); await TransactionModel.updateOne( { _id: tx._id, householdId: ctx.m.householdId }, { $set: { ...d, updatedBy: actor, ...(balanceChanged ? { reconciledAccountIds: [] } : {}), }, }, ); await applyDue(contact, input, actor); return ok(undefined); } // --- Ekstre --- export type ContactStatementRow = { item: TransactionListItem; deltaMinor: number; balanceMinor: number; }; export type ContactStatement = { contact: ContactView; /** Yeniden eskiye. */ rows: ContactStatementRow[]; canWrite: boolean; }; export async function getContactStatement( userId: string, householdId: string, contactId: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const contact = await loadContact(ctx, contactId, userId); if (!contact) return null; const txs = (await TransactionModel.find({ householdId: ctx.m.householdId, contactId: contact._id, contactKind: { $ne: null }, status: "posted", deletedAt: null, }) .sort({ date: 1, createdAt: 1 }) .limit(5000) .lean()) as unknown as LeanTx[]; const names = await userNames(txs.map((x) => x.createdBy as Types.ObjectId)); let balance = contact.openingBalanceMinor; const rows = txs.map((x): ContactStatementRow => { const deltaMinor = contactDelta(x.contactKind!, x.amountMinor); balance += deltaMinor; const item = toItem(ctx, x, names); // Başkasının gizli hesabıyla yapılan hareket: hesap adı gizlenir. for (const a of [item.account, item.toAccount]) if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount; return { item, deltaMinor, balanceMinor: balance }; }); const totals = new Map([ [ String(contact._id), { delta: balance - contact.openingBalanceMinor, last: txs.at(-1)?.date ?? null }, ], ]) as Map; return { contact: toView(contact, totals, todayIstanbul()), rows: rows.reverse(), canWrite: hasHouseholdRole(ctx.m.role, "member"), }; } /** Dashboard: vadesi geçmiş veya yaklaşan (hatırlatma penceresindeki) cari sayısı. */ export async function countDueContacts(userId: string, householdId: string): Promise { const list = await listContacts(userId, householdId); return list?.contacts.filter((c) => !c.archived && c.due !== "none").length ?? 0; }