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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user