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; + } } }