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