From dfb83d6fd4561b4c4b25f60ea66feaf6937827e4 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:58:37 +0300 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20Faz=208=20kararlar=C4=B1n=C4=B1=20k?= =?UTF-8?q?aydet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/PROMPT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/PROMPT.md b/docs/PROMPT.md index 0137d77..b5e1c48 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -319,6 +319,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır. - **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir. +- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. --- From 5d370a062e27cae82f222a4919348f3abd6a685f Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:58:37 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(contacts):=20cari=20ki=C5=9Filer,=20he?= =?UTF-8?q?sap=20=E2=86=94=20cari=20transferleri,=20cari=20ekstresi=20ve?= =?UTF-8?q?=20vade=20uyar=C4=B1s=C4=B1,=20007=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/007-contacts.ts | 15 + scripts/migrations/index.ts | 3 +- src/features/attachments/service.int.test.ts | 2 +- src/features/contacts/actions.ts | 88 ++++ src/features/contacts/service.int.test.ts | 272 +++++++++++ src/features/contacts/service.ts | 441 ++++++++++++++++++ src/features/recurring/service.int.test.ts | 2 +- src/features/recurring/service.ts | 3 +- src/features/transactions/service.int.test.ts | 2 +- src/features/transactions/service.ts | 61 ++- src/features/transactions/statement.ts | 5 +- src/models/audit-log.ts | 2 + src/models/contact.ts | 41 ++ src/models/transaction.ts | 7 +- src/shared/contacts.test.ts | 25 + src/shared/contacts.ts | 37 ++ src/shared/schemas/contact.ts | 58 +++ src/shared/transactions.ts | 22 +- 18 files changed, 1060 insertions(+), 26 deletions(-) create mode 100644 scripts/migrations/007-contacts.ts create mode 100644 src/features/contacts/actions.ts create mode 100644 src/features/contacts/service.int.test.ts create mode 100644 src/features/contacts/service.ts create mode 100644 src/models/contact.ts create mode 100644 src/shared/contacts.test.ts create mode 100644 src/shared/contacts.ts create mode 100644 src/shared/schemas/contact.ts diff --git a/scripts/migrations/007-contacts.ts b/scripts/migrations/007-contacts.ts new file mode 100644 index 0000000..1492676 --- /dev/null +++ b/scripts/migrations/007-contacts.ts @@ -0,0 +1,15 @@ +import { ContactModel } from "@/models/contact"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "007-contacts", + description: "contacts index'leri; işlemlerde cari alanları ve cari index'i", + async up(db) { + await db + .collection("transactions") + .updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } }); + await ContactModel.createIndexes(); + await TransactionModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index a4e2f90..5d7850c 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -6,10 +6,11 @@ import { migration as m003 } from "./003-transactions"; import { migration as m004 } from "./004-attachments-reconciliation"; import { migration as m005 } from "./005-recurring"; import { migration as m006 } from "./006-card-statements"; +import { migration as m007 } from "./007-contacts"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007]; const COLLECTION = "migrations"; diff --git a/src/features/attachments/service.int.test.ts b/src/features/attachments/service.int.test.ts index 94582f1..d4e7fc4 100644 --- a/src/features/attachments/service.int.test.ts +++ b/src/features/attachments/service.int.test.ts @@ -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], }), diff --git a/src/features/contacts/actions.ts b/src/features/contacts/actions.ts new file mode 100644 index 0000000..8be7c3a --- /dev/null +++ b/src/features/contacts/actions.ts @@ -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 = {}; + 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> { + 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; + 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 { + 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> { + 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; + 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; +} diff --git a/src/features/contacts/service.int.test.ts b/src/features/contacts/service.int.test.ts new file mode 100644 index 0000000..f8eae1a --- /dev/null +++ b/src/features/contacts/service.int.test.ts @@ -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>; +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 = {}) => + contactFormSchema.parse({ name: "Test Kişi", openingDate: today, ...over }); +const entry = (over: Partial) => + 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)); + }); +}); diff --git a/src/features/contacts/service.ts b/src/features/contacts/service.ts new file mode 100644 index 0000000..1ea817f --- /dev/null +++ b/src/features/contacts/service.ts @@ -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, + 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, + 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; +} diff --git a/src/features/recurring/service.int.test.ts b/src/features/recurring/service.int.test.ts index f912a0d..14af846 100644 --- a/src/features/recurring/service.int.test.ts +++ b/src/features/recurring/service.int.test.ts @@ -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: [], diff --git a/src/features/recurring/service.ts b/src/features/recurring/service.ts index a83101b..39aea8e 100644 --- a/src/features/recurring/service.ts +++ b/src/features/recurring/service.ts @@ -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( diff --git a/src/features/transactions/service.int.test.ts b/src/features/transactions/service.int.test.ts index d273661..6f5947e 100644 --- a/src/features/transactions/service.int.test.ts +++ b/src/features/transactions/service.int.test.ts @@ -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); }); diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index 6d8db53..50e4999 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -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( 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>>; -/** 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 { const f: Record = { 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): 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): 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, }); diff --git a/src/features/transactions/statement.ts b/src/features/transactions/statement.ts index c77f193..950ddca 100644 --- a/src/features/transactions/statement.ts +++ b/src/features/transactions/statement.ts @@ -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) { diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index d333a01..fae2046 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -28,6 +28,8 @@ export const AUDIT_ACTIONS = [ "transaction.delete", "transaction.restore", "recurring.delete", + "contact.archive", + "contact.unarchive", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/contact.ts b/src/models/contact.ts new file mode 100644 index 0000000..a07fa7d --- /dev/null +++ b/src/models/contact.ts @@ -0,0 +1,41 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { CONTACT_RELATIONS } from "@/shared/contacts"; + +/** + * Cari kişi (Bölüm 6.4). Hareketler `transactions` içinde transfer olarak tutulur (`contactId`); + * bakiye: açılış + çıkışlar − girişler (+ bana borçlu, − ben borçluyum). + * "Yalnızca ben" işaretli cari ve hareketleri yalnızca sahibine görünür. + */ +const contactSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + relation: { type: String, enum: CONTACT_RELATIONS, default: "other" }, + phone: { type: String, default: "", maxlength: 30 }, + note: { type: String, default: "", maxlength: 500 }, + /** Uygulamaya başlamadan önceki bakiye (+ bana borçlu, − ben borçluyum). */ + openingBalanceMinor: { type: Number, default: 0 }, + openingDate: { type: Date, required: true }, + /** Kalan bakiyenin vadesi ("YYYY-MM-DD") ve kaç gün önce uyarılacağı. */ + dueDate: { type: String, default: null }, + reminderDays: { type: Number, default: 3, min: 0, max: 60 }, + isPrivate: { type: Boolean, default: false }, + ownerUserId: { type: Schema.Types.ObjectId, default: null }, + archivedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "contacts" }, +); + +contactSchema.index({ householdId: 1, archivedAt: 1, name: 1 }); +contactSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type Contact = InferSchemaType; + +export const ContactModel: Model = + (models.Contact as Model | undefined) ?? model("Contact", contactSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index f0382ef..ff08541 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -1,4 +1,5 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { CONTACT_ENTRY_KINDS } from "@/shared/contacts"; import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions"; /** @@ -21,7 +22,8 @@ const transactionSchema = new Schema( status: { type: String, enum: TRANSACTION_STATUSES, default: "posted" }, /** İstanbul gününün başlangıç anı (UTC). */ date: { type: Date, required: true }, - accountId: { type: Schema.Types.ObjectId, required: true }, + /** Kaynak hesap. Cariden girişte (borç alma, tahsilat) null; hedef `toAccountId`. */ + accountId: { type: Schema.Types.ObjectId, default: null }, toAccountId: { type: Schema.Types.ObjectId, default: null }, amountMinor: { type: Number, required: true, min: 1 }, /** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */ @@ -53,7 +55,9 @@ const transactionSchema = new Schema( default: null, }, statementId: { type: Schema.Types.ObjectId, default: null }, + /** Cari transferi: karşı taraf kişi (hesabın biri boş). */ contactId: { type: Schema.Types.ObjectId, default: null }, + contactKind: { type: String, enum: [...CONTACT_ENTRY_KINDS, null], default: null }, /** * Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında * tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar. @@ -76,6 +80,7 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 }); transactionSchema.index({ householdId: 1, tags: 1 }); +transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true }); transactionSchema.index({ "installment.planId": 1 }, { sparse: true }); transactionSchema.index( { recurringRuleId: 1, occurrenceIndex: 1 }, diff --git a/src/shared/contacts.test.ts b/src/shared/contacts.test.ts new file mode 100644 index 0000000..3e45ce5 --- /dev/null +++ b/src/shared/contacts.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { contactDelta, dueState, isOutgoing } from "./contacts"; +import { addDays } from "./recurrence"; + +describe("cari", () => { + it("yön ve bakiye etkisi", () => { + expect(isOutgoing("lend")).toBe(true); + expect(isOutgoing("repay")).toBe(true); + expect(isOutgoing("borrow")).toBe(false); + expect(contactDelta("lend", 100)).toBe(100); + expect(contactDelta("repay", 100)).toBe(100); + expect(contactDelta("borrow", 100)).toBe(-100); + expect(contactDelta("collect", 100)).toBe(-100); + }); + + it("vade durumu", () => { + const today = "2026-11-05"; + expect(dueState(100, null, 3, today, addDays)).toBe("none"); + expect(dueState(0, "2026-11-01", 3, today, addDays)).toBe("none"); + expect(dueState(-100, "2026-11-04", 3, today, addDays)).toBe("overdue"); + expect(dueState(100, "2026-11-08", 3, today, addDays)).toBe("upcoming"); + expect(dueState(100, "2026-11-09", 3, today, addDays)).toBe("none"); + expect(dueState(100, "2026-11-05", 0, today, addDays)).toBe("upcoming"); + }); +}); diff --git a/src/shared/contacts.ts b/src/shared/contacts.ts new file mode 100644 index 0000000..0761e70 --- /dev/null +++ b/src/shared/contacts.ts @@ -0,0 +1,37 @@ +/** Cari hesaplar (Bölüm 6.4). */ + +export const CONTACT_RELATIONS = ["family", "friend", "neighbor", "work", "other"] as const; +export type ContactRelation = (typeof CONTACT_RELATIONS)[number]; + +/** + * Cari hareket türleri. Hesaptan cariye (çıkış): borç verdim, borcumu ödedim. + * Cariden hesaba (giriş): borç aldım, tahsilat yaptım. Hepsi transferdir; gelir/gider değildir. + */ +export const CONTACT_ENTRY_KINDS = ["lend", "repay", "borrow", "collect"] as const; +export type ContactEntryKind = (typeof CONTACT_ENTRY_KINDS)[number]; + +/** Para hesaptan cariye mi gidiyor? */ +export const isOutgoing = (kind: ContactEntryKind) => kind === "lend" || kind === "repay"; + +/** + * Cari bakiyesi (kuruş): + "Bana borçlu", − "Ben borçluyum". + * Çıkışlar (verdim/ödedim) bakiyeyi artırır, girişler (aldım/tahsil ettim) azaltır. + */ +export function contactDelta(kind: ContactEntryKind, amountMinor: number): number { + return isOutgoing(kind) ? amountMinor : -amountMinor; +} + +export type DueState = "none" | "upcoming" | "overdue"; + +/** Vade uyarısı: bakiye kapanmamışsa, vade geçtiyse "overdue", hatırlatma penceresindeyse "upcoming". */ +export function dueState( + balanceMinor: number, + dueDate: string | null, + reminderDays: number, + today: string, + addDays: (ymd: string, n: number) => string, +): DueState { + if (!dueDate || balanceMinor === 0) return "none"; + if (dueDate < today) return "overdue"; + return addDays(today, reminderDays) >= dueDate ? "upcoming" : "none"; +} diff --git a/src/shared/schemas/contact.ts b/src/shared/schemas/contact.ts new file mode 100644 index 0000000..1628cd8 --- /dev/null +++ b/src/shared/schemas/contact.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { CONTACT_ENTRY_KINDS, CONTACT_RELATIONS } from "../contacts"; +import { parseMoney } from "../money"; +import { moneyInputSchema } from "./account"; + +const v = tr.validation; +const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date); +const optionalYmd = z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()); +const reminderDays = z.coerce.number().int().min(0).max(60).default(3); + +/** Cari kişi formu (web + mobil). */ +export const contactFormSchema = z.object({ + name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax), + relation: z.enum(CONTACT_RELATIONS).default("other"), + phone: z + .string() + .trim() + .max(30) + .regex(/^[0-9+()\s-]*$/, v.phone) + .default(""), + note: z.string().trim().max(500).default(""), + /** + bana borçlu, − ben borçluyum. */ + openingBalance: z.string().default("").pipe(moneyInputSchema), + openingDate: ymd, + isPrivate: z.boolean().default(false), + dueDate: optionalYmd, + reminderDays, +}); +export type ContactFormInput = z.input; +export type ContactForm = z.output; + +/** Cari hareketi: hesaptan cariye veya cariden hesaba transfer. */ +export const contactEntrySchema = z.object({ + kind: z.enum(CONTACT_ENTRY_KINDS), + accountId: z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount), + amount: z + .string() + .trim() + .transform((s, ctx) => { + const minor = parseMoney(s); + if (minor === null || minor <= 0) { + ctx.addIssue({ + code: "custom", + message: minor === null ? v.money : tr.transactions.validation.amountPositive, + }); + return z.NEVER; + } + return minor; + }), + date: ymd, + description: z.string().trim().max(500).default(""), + /** Verilirse carinin vadesi güncellenir (boş string → vade kaldırılır). */ + dueDate: optionalYmd.optional(), + reminderDays: reminderDays.optional(), +}); +export type ContactEntryInput = z.input; +export type ContactEntry = z.output; diff --git a/src/shared/transactions.ts b/src/shared/transactions.ts index a62e0ce..9cfacbb 100644 --- a/src/shared/transactions.ts +++ b/src/shared/transactions.ts @@ -13,7 +13,8 @@ export type EffectInput = { type: TransactionType; status: TransactionStatus; amountMinor: number; - accountId: string; + /** Cariden girişte (borç alma, tahsilat) boştur. */ + accountId: string | null; toAccountId?: string | null; deleted?: boolean; }; @@ -27,16 +28,17 @@ export function transactionEffects(t: EffectInput): { accountId: string; deltaMi if (t.status !== "posted" || t.deleted) return []; switch (t.type) { case "income": - return [{ accountId: t.accountId, deltaMinor: t.amountMinor }]; + return t.accountId ? [{ accountId: t.accountId, deltaMinor: t.amountMinor }] : []; case "expense": - return [{ accountId: t.accountId, deltaMinor: -t.amountMinor }]; - case "transfer": - return t.toAccountId - ? [ - { accountId: t.accountId, deltaMinor: -t.amountMinor }, - { accountId: t.toAccountId, deltaMinor: t.amountMinor }, - ] - : []; + return t.accountId ? [{ accountId: t.accountId, deltaMinor: -t.amountMinor }] : []; + case "transfer": { + // Hesap ↔ hesap; cari transferinde bir taraf boştur (cariye çıkış: toAccountId yok, + // cariden giriş: accountId yok). + const out: { accountId: string; deltaMinor: number }[] = []; + if (t.accountId) out.push({ accountId: t.accountId, deltaMinor: -t.amountMinor }); + if (t.toAccountId) out.push({ accountId: t.toAccountId, deltaMinor: t.amountMinor }); + return out; + } } } From b6c6be5c979c84d4ba6360267c7cd9e4d8d33615 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:58:37 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(ui):=20cari=20hesaplar=20sayfalar?= =?UTF-8?q?=C4=B1,=20hareket=20formu=20ve=20vade=20uyar=C4=B1s=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/cari/[id]/page.tsx | 17 + src/app/(app)/cari/page.tsx | 22 + src/app/(app)/dashboard/page.tsx | 4 + src/components/layout/nav-config.ts | 4 +- .../contacts/components/contact-dialogs.tsx | 434 ++++++++++++++++++ .../components/contact-statement-view.tsx | 155 +++++++ .../contacts/components/contacts-view.tsx | 186 ++++++++ .../contacts/components/due-banner.tsx | 29 ++ .../components/transaction-form.tsx | 2 +- .../components/transaction-parts.tsx | 57 ++- src/i18n/tr.ts | 77 ++++ 11 files changed, 975 insertions(+), 12 deletions(-) create mode 100644 src/app/(app)/cari/[id]/page.tsx create mode 100644 src/app/(app)/cari/page.tsx create mode 100644 src/features/contacts/components/contact-dialogs.tsx create mode 100644 src/features/contacts/components/contact-statement-view.tsx create mode 100644 src/features/contacts/components/contacts-view.tsx create mode 100644 src/features/contacts/components/due-banner.tsx diff --git a/src/app/(app)/cari/[id]/page.tsx b/src/app/(app)/cari/[id]/page.tsx new file mode 100644 index 0000000..0db1f1c --- /dev/null +++ b/src/app/(app)/cari/[id]/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ContactStatementView } from "@/features/contacts/components/contact-statement-view"; +import { getContactStatement } from "@/features/contacts/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.contacts.statement }; + +/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */ +export default async function ContactPage({ params }: PageProps<"/cari/[id]">) { + const { user, household } = await requireActiveHousehold("viewer"); + const { id } = await params; + const data = await getContactStatement(user.id, household.id, id); + if (!data) notFound(); + return ; +} diff --git a/src/app/(app)/cari/page.tsx b/src/app/(app)/cari/page.tsx new file mode 100644 index 0000000..5552f78 --- /dev/null +++ b/src/app/(app)/cari/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { ContactsView } from "@/features/contacts/components/contacts-view"; +import { listContacts } from "@/features/contacts/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.contacts.title }; + +export default async function ContactsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await listContacts(user.id, household.id); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 7ea3637..4f6aae1 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; +import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -63,6 +64,9 @@ export default async function DashboardPage() { + + + }> diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 239e67f..d21cd79 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 7.2; +export const CURRENT_PHASE = 8.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -37,7 +37,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 }, { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 }, - { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, + { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, diff --git a/src/features/contacts/components/contact-dialogs.tsx b/src/features/contacts/components/contact-dialogs.tsx new file mode 100644 index 0000000..52ea6e6 --- /dev/null +++ b/src/features/contacts/components/contact-dialogs.tsx @@ -0,0 +1,434 @@ +"use client"; + +import { Loader2, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + getTransactionFormOptionsAction, + setTransactionDeletedAction, +} from "@/features/transactions/actions"; +import { AccountSelect } from "@/features/transactions/components/transaction-form"; +import type { TransactionFormOptions, TransactionListItem } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { + CONTACT_ENTRY_KINDS, + CONTACT_RELATIONS, + isOutgoing, + type ContactEntryKind, + type ContactRelation, +} from "@/shared/contacts"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { saveContactAction, saveContactEntryAction } from "../actions"; +import type { ContactView } from "../service"; + +const t = tr.contacts; +type Errors = Record; + +/** Cari oluştur / düzenle. */ +export function ContactFormDialog({ + open, + contact, + onOpenChange, +}: { + open: boolean; + contact: ContactView | null; + onOpenChange: (open: boolean) => void; +}) { + return open ? ( + onOpenChange(false)} /> + ) : null; +} + +function ContactForm({ contact, onClose }: { contact: ContactView | null; onClose: () => void }) { + const [v, setV] = useState({ + name: contact?.name ?? "", + relation: (contact?.relation ?? "friend") as ContactRelation, + phone: contact?.phone ?? "", + note: contact?.note ?? "", + openingBalance: contact?.openingBalanceMinor + ? formatMoneyInput(contact.openingBalanceMinor) + : "", + openingDate: contact?.openingDate ?? todayIstanbul(), + dueDate: contact?.dueDate ?? "", + reminderDays: String(contact?.reminderDays ?? 3), + isPrivate: contact?.isPrivate ?? false, + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, value: (typeof v)[K]) => + setV((x) => ({ ...x, [k]: value })); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveContactAction(contact?.id ?? null, v); + setBusy(false); + if (r.ok) { + toast.success(contact ? t.toast.updated : t.toast.created); + onClose(); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + const f = t.fields; + return ( + !o && onClose()} + title={contact ? t.editTitle : t.createTitle} + footer={ + <> + + + + } + > +
+ + + {f.name} + set("name", e.target.value)} + aria-invalid={Boolean(errors.name)} + autoFocus + /> + {errors.name ? {errors.name} : null} + +
+ + {f.relation} + + + + {f.phone} + set("phone", e.target.value)} + aria-invalid={Boolean(errors.phone)} + /> + {errors.phone ? {errors.phone} : null} + +
+
+ + {f.openingBalance} + set("openingBalance", x)} + allowNegative + /> + {errors.openingBalance ? {errors.openingBalance} : null} + + + {tr.transactions.fields.date} + set("openingDate", e.target.value)} + /> + +
+ {f.openingHint} + setV((x) => ({ ...x, dueDate: d, reminderDays: r }))} + errors={errors} + /> + + {f.note} +