feat(contacts): cari kişiler, hesap ↔ cari transferleri, cari ekstresi ve vade uyarısı, 007 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:58:37 +03:00
parent dfb83d6fd4
commit 5d370a062e
18 changed files with 1060 additions and 26 deletions
+52 -9
View File
@@ -13,10 +13,12 @@ import { writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
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";
@@ -68,7 +70,7 @@ export async function loadContext(
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
await postDueInstallments(m.householdId);
const [accounts, categories] = await Promise.all([
const [accounts, categories, contacts] = await Promise.all([
AccountModel.find({ householdId: m.householdId })
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
.lean(),
@@ -84,6 +86,9 @@ export async function loadContext(
ancestors: 1,
})
.lean(),
ContactModel.find({ householdId: m.householdId })
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
.lean(),
]);
const accountMap = new Map<string, AccountInfo>(
accounts.map((a) => [
@@ -126,18 +131,39 @@ export async function loadContext(
]),
);
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
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);
return {
m,
accountMap,
categoryMap,
contactMap,
hiddenAccountIds,
hiddenContactIds,
flatCategories: flat,
};
}
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
/** Gizli hesaplara veya gizli carilere 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 };
}
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
return f;
}
@@ -221,7 +247,7 @@ function checkCategory(
}
type Existing = {
accountId: Types.ObjectId;
accountId?: Types.ObjectId | null;
toAccountId?: Types.ObjectId | null;
categoryId?: Types.ObjectId | null;
splits?: { categoryId: Types.ObjectId }[];
@@ -238,7 +264,7 @@ export function buildDoc(
const accountId = checkAccount(
ctx,
input.accountId,
existing ? String(existing.accountId) : null,
existing?.accountId ? String(existing.accountId) : null,
);
if (!accountId)
return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid });
@@ -445,6 +471,8 @@ export async function updateTransaction(
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);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as {
@@ -467,7 +495,7 @@ export async function updateTransaction(
d.type !== current.type ||
d.amountMinor !== current.amountMinor ||
d.date.getTime() !== new Date(current.date).getTime() ||
!d.accountId.equals(current.accountId) ||
String(d.accountId) !== String(current.accountId ?? "") ||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
try {
await mongoose.connection.transaction(async (session) => {
@@ -567,8 +595,11 @@ export type TransactionListItem = {
status: TransactionStatus;
date: string;
amountMinor: number;
account: { id: string; name: string };
/** 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;
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
splitCount: number;
payeeName: string;
@@ -629,7 +660,7 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
status: tx.status,
date: toIstanbulYmd(tx.date),
amountMinor: tx.amountMinor,
account: acc(tx.accountId)!,
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 }
@@ -642,6 +673,14 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
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,
installment: tx.installment?.planId
? {
planId: String(tx.installment.planId),
@@ -836,7 +875,11 @@ export async function exportTransactions(
} else {
out.push({
...common,
category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
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,
});