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
+1 -1
View File
@@ -245,7 +245,7 @@ describe("işleme bağlama", () => {
cakir,
txId,
await form({
accountId: detail!.account.id,
accountId: detail!.account!.id,
categoryId: detail!.categoryId,
attachmentIds: [a3.id, a4.id],
}),
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
import {
createContact,
createContactEntry,
setContactArchived,
updateContact,
updateContactEntry,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function revalidate() {
revalidatePath("/", "layout");
}
export async function saveContactAction(
contactId: unknown,
input: unknown,
): Promise<ActionResult<unknown>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = contactFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
let result: ActionResult<unknown>;
if (contactId === null || contactId === undefined) {
result = await createContact(ctx.user.id, ctx.household.id, parsed.data);
} else {
const id = idSchema.safeParse(contactId);
if (!id.success) return fail(tr.common.notFound);
result = await updateContact(ctx.user.id, ctx.household.id, id.data, parsed.data);
}
if (result.ok) revalidate();
return result;
}
export async function setContactArchivedAction(
contactId: unknown,
archived: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(contactId);
const a = z.boolean().safeParse(archived);
if (!id.success || !a.success) return fail(tr.common.notFound);
const result = await setContactArchived(ctx.user.id, ctx.household.id, id.data, a.data);
if (result.ok) revalidate();
return result;
}
/** Yeni hareket (transactionId boş) veya mevcut hareketi düzenle. */
export async function saveContactEntryAction(
contactId: unknown,
transactionId: unknown,
input: unknown,
): Promise<ActionResult<unknown>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = contactEntrySchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
let result: ActionResult<unknown>;
if (transactionId) {
const tid = idSchema.safeParse(transactionId);
if (!tid.success) return fail(tr.common.notFound);
result = await updateContactEntry(ctx.user.id, ctx.household.id, tid.data, parsed.data);
} else {
const cid = idSchema.safeParse(contactId);
if (!cid.success) return fail(tr.common.notFound);
result = await createContactEntry(ctx.user.id, ctx.household.id, cid.data, parsed.data);
}
if (result.ok) revalidate();
return result;
}
+272
View File
@@ -0,0 +1,272 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import {
getTransaction,
listTransactions,
updateTransaction,
} from "@/features/transactions/service";
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
import { AccountModel } from "@/models/account";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import {
contactEntrySchema,
contactFormSchema,
type ContactEntryInput,
type ContactFormInput,
} from "@/shared/schemas/contact";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
countDueContacts,
createContact,
createContactEntry,
getContactStatement,
listContacts,
setContactArchived,
updateContact,
updateContactEntry,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const contactForm = (over: Partial<ContactFormInput> = {}) =>
contactFormSchema.parse({ name: "Test Kişi", openingDate: today, ...over });
const entry = (over: Partial<ContactEntryInput>) =>
contactEntrySchema.parse({ kind: "lend", amount: "1000", date: today, ...over });
async function balance(userId: string, accountId: string) {
return (await listAccounts(userId, cakir))!.accounts.find((a) => a.id === accountId)!
.balanceMinor!;
}
async function contactBalance(userId: string, id: string) {
return (await listContacts(userId, cakir))!.contacts.find((c) => c.id === id)!.balanceMinor;
}
const sums = async () =>
(await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
describe("cari defteri", () => {
let ahmet = "";
let lendId = "";
it("borç verme ve tahsilat: hesap bakiyeleri değişir, gelir/gider toplamlarına girmez", async () => {
const ziraat = await acc("Ziraat Maaş");
const cuzdan = await acc("Cüzdan");
const [zBefore, cBefore, sBefore] = [
await balance(ayse, ziraat),
await balance(ayse, cuzdan),
await sums(),
];
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Kemal Test", relation: "friend" }),
);
expect(c.ok).toBe(true);
ahmet = c.ok ? c.data.id : "";
const l = await createContactEntry(
ayse,
cakir,
ahmet,
entry({ accountId: ziraat, amount: "5.000" }),
);
expect(l.ok).toBe(true);
lendId = l.ok ? l.data.id : "";
await createContactEntry(
emre,
cakir,
ahmet,
entry({ kind: "collect", accountId: cuzdan, amount: "2.000" }),
);
expect(await balance(ayse, ziraat)).toBe(zBefore - 5_000_00);
expect(await balance(ayse, cuzdan)).toBe(cBefore + 2_000_00);
expect(await contactBalance(ayse, ahmet)).toBe(3_000_00);
expect(await sums()).toEqual(sBefore);
const detail = (await getTransaction(ayse, cakir, lendId))!;
expect(detail).toMatchObject({
type: "transfer",
contact: { kind: "lend", name: "Kemal Test" },
});
expect(detail.toAccount).toBeNull();
});
it("borç alma ve geri ödeme; açılış bakiyesi; ekstre yürüyen bakiye", async () => {
const ziraat = await acc("Ziraat Maaş");
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Hasan Amca", relation: "family", openingBalance: "-1.000" }),
);
const hasan = c.ok ? c.data.id : "";
await createContactEntry(
ayse,
cakir,
hasan,
entry({ kind: "borrow", accountId: ziraat, amount: "10.000" }),
);
await createContactEntry(
ayse,
cakir,
hasan,
entry({ kind: "repay", accountId: ziraat, amount: "4.000" }),
);
expect(await contactBalance(ayse, hasan)).toBe(-7_000_00);
const s = (await getContactStatement(ayse, cakir, hasan))!;
expect(s.rows).toHaveLength(2);
expect(s.rows[0]!.balanceMinor).toBe(-7_000_00);
expect(s.rows[1]!.balanceMinor).toBe(-11_000_00);
expect(s.contact.balanceMinor).toBe(-7_000_00);
const list = (await listContacts(ayse, cakir))!;
expect(list.totals.iOweMinor).toBeGreaterThanOrEqual(7_000_00);
expect(list.totals.owesMeMinor).toBeGreaterThanOrEqual(3_000_00);
});
it("hareket düzenleme: tür değişince yön ve bakiyeler değişir; işlem formuyla düzenlenemez", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ayse, ziraat);
const u = await updateContactEntry(
ayse,
cakir,
lendId,
entry({ kind: "borrow", accountId: ziraat, amount: "5.000" }),
);
expect(u.ok).toBe(true);
expect(await balance(ayse, ziraat)).toBe(before + 10_000_00);
expect(await contactBalance(ayse, ahmet)).toBe(-7_000_00);
const d = (await getTransaction(ayse, cakir, lendId))!;
expect(d.account).toBeNull();
expect(d.toAccount?.id).toBe(ziraat);
const viaForm = await updateTransaction(ayse, cakir, lendId, {
type: "transfer",
amount: 1,
date: today,
accountId: ziraat,
toAccountId: await acc("Cüzdan"),
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
installments: 1,
attachmentIds: [],
});
expect(viaForm.ok).toBe(false);
});
it("vade uyarısı: yaklaşan, geçmiş, kapalı bakiye", async () => {
const c = await createContact(
ayse,
cakir,
contactForm({
name: "Vade Kişi",
openingBalance: "500",
dueDate: addDays(today, 2),
reminderDays: 3,
}),
);
const id = c.ok ? c.data.id : "";
const before = await countDueContacts(ayse, cakir);
let v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("upcoming");
await updateContact(
ayse,
cakir,
id,
contactForm({ name: "Vade Kişi", openingBalance: "500", dueDate: addDays(today, -1) }),
);
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("overdue");
await updateContact(
ayse,
cakir,
id,
contactForm({ name: "Vade Kişi", openingBalance: "0", dueDate: addDays(today, -1) }),
);
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("none");
expect(await countDueContacts(ayse, cakir)).toBe(before - 1);
});
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(
false,
);
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
expect(
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
).toBe(false);
expect((await setContactArchived(ayse, cakir, ahmet, false)).ok).toBe(true);
});
});
describe("yetki ve gizlilik", () => {
it("izleyici yazamaz; hane dışı göremez; başka hanenin hesabı kullanılamaz", async () => {
expect((await createContact(zeynep, cakir, contactForm({ name: "X" }))).ok).toBe(false);
expect(await listContacts(fatma, cakir)).toBeNull();
const c = await createContact(ayse, cakir, contactForm({ name: "Hesap Testi" }));
const id = c.ok ? c.data.id : "";
const otherHousehold = String(
(await AccountModel.findOne({ name: "Vakıfbank Emekli" }).lean())!._id,
);
expect(
(await createContactEntry(ayse, cakir, id, entry({ accountId: otherHousehold }))).ok,
).toBe(false);
});
it("yalnızca ben cari: başkası listede, ekstrede ve işlemlerde görmez; hesap ekstresinde ad gizlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Gizli Kişi", isPrivate: true }),
);
const id = c.ok ? c.data.id : "";
const e = await createContactEntry(
ayse,
cakir,
id,
entry({ accountId: ziraat, amount: "750" }),
);
const txId = e.ok ? e.data.id : "";
expect((await listContacts(emre, cakir))!.contacts.some((x) => x.id === id)).toBe(false);
expect(await getContactStatement(emre, cakir, id)).toBeNull();
expect(await getTransaction(emre, cakir, txId)).toBeNull();
expect((await createContactEntry(emre, cakir, id, entry({ accountId: ziraat }))).ok).toBe(
false,
);
const st = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
const row = st.rows.find((r) => r.item.id === txId)!;
expect(row.restricted).toBe(true);
expect(row.item.contact!.name).not.toBe("Gizli Kişi");
// Ortak hesabın bakiyesi herkes için aynı.
expect(await balance(emre, ziraat)).toBe(await balance(ayse, ziraat));
});
});
+441
View File
@@ -0,0 +1,441 @@
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" },
},
},
{
$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<string, { delta: number; last: Date }>,
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<ContactList | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const docs = (await ContactModel.find({ householdId: ctx.m.householdId }).lean<ContactDoc[]>())
.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<ContactDoc>();
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ContactStatement | null> {
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,
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<string, { delta: number; last: Date }>;
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<number> {
const list = await listContacts(userId, householdId);
return list?.contacts.filter((c) => !c.archived && c.due !== "none").length ?? 0;
}
+1 -1
View File
@@ -196,7 +196,7 @@ describe("kural ve planlı kayıtlar", () => {
type: "expense",
amount: 777_00,
date: d.date,
accountId: d.account.id,
accountId: d.account!.id,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
+2 -1
View File
@@ -376,7 +376,8 @@ export async function confirmOccurrence(
ctx.m.householdId,
tx.payeeName ?? "",
tx.type,
tx.accountId,
// Planlı kayıtlar cari hareketi değildir; kaynak hesap her zaman dolu.
tx.accountId!,
tx.categoryId ?? null,
);
await TransactionModel.updateOne(
@@ -383,7 +383,7 @@ describe("filtreler", () => {
);
expect(byAccount!.total).toBeGreaterThan(0);
expect(
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account.id === cuzdan),
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account!.id === cuzdan),
).toBe(true);
});
+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,
});
+4 -1
View File
@@ -201,7 +201,10 @@ export async function getAccountStatement(
const effects = rowsAsc.map((tx) => {
const item = toItem(ctx, tx, names);
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
const restricted = counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
const hiddenContact = Boolean(item.contact && ctx.contactMap.get(item.contact.id)?.hidden);
if (hiddenContact && item.contact) item.contact.name = tr.transactions.list.hiddenContact;
const restricted =
hiddenContact || counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
if (restricted) {
// Gizli karşı hesabın adı sızmasın.
for (const a of counterpart) {